feat: select a wake-up adapter automatically - #77
Conversation
A commit in a web process did not wake a worker process unless somebody configured an adapter, so delivery waited out the polling interval. The pieces existed and were measured. Nothing chose between them. config.wake_up_adapter now takes a name or an adapter, the way config.cache_store and config.active_job.queue_adapter do, rather than one setting for a mode and another for an instance. It defaults to :automatic, which prefers a configured Redis URL, then PostgreSQL notifications, then polling. :in_process opts out. An unknown name raises rather than quietly polling, because a typo that silently costs a second of latency is the failure this is meant to remove. LISTEN does not survive a transaction pooler, so the PostgreSQL session is probed first. The probe reports three outcomes rather than two. A definite false means a pooler took the session, and polling is chosen with a warning. A probe that could not run at all is not evidence of a pooler, so notifications are still chosen and the reason says the session was not probed. Conflating those two would downgrade any deployment whose connection cannot answer the probe. The choice is now readable. SolidObjects.wake_up.capability names the adapter, whether it crosses processes, its measured floor, and why. The doctor reports it. The polling-only warning now fires on what was installed rather than on whether a setting was set, which is what it meant to ask: the old guard returned early for any configured adapter, and :automatic is always configured. Two tests moved to :in_process rather than changing what they assert. The polling warning test is about the warning, not about selection, and the enqueue statement count is about the enqueue transaction, not about the NOTIFY that a cross-process adapter adds after the commit.
|
Two claims in selection were not supported by what it measured. `configured` overwrote the capability that an adapter reports about itself. A configured `SolidObjects::WakeUp` signals in one process only and says so, but selection relabelled it `crosses_processes: true`, so the doctor reported PASS and the process registry dropped the warning that this exact topology needs. Selection now keeps the capability that an adapter provides and labels only an adapter that provides none. The PostgreSQL probe set `application_name` and read it back on the next statement. A transaction pooler can hand the same backend to two consecutive autocommit statements, so the read-back succeeds while `LISTEN` still has no session affinity, and selection memoised notification support that never fires. The probe now listens, sends one `NOTIFY` from a second connection, and waits up to two seconds for it to arrive. Nothing but a delivered notification selects notifications, and a probe that cannot run falls back to polling rather than assume. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`SolidObjects.wake_up` memoised with `||=` and no lock. One selection was
cheap, so the race cost nothing visible. A probe that opens connections
and waits for a notification is not cheap: eight threads that raced for
the first use each ran a full selection, and on PostgreSQL that took
every free pool connection and left four callers waiting out the
checkout timeout.
`EnqueueTest#test_allocates_unique_sequences_under_concurrent_enqueue`
showed it:
ActiveRecord::ConnectionTimeoutError: could not obtain a connection
from the pool within 5.000 seconds
Selection now runs under a mutex, so one thread probes and the rest read
the result. The probe also listens on its own channel rather than the
production one, so a booting process no longer wakes every waiting role
in the deployment.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Pushed 39af457 after the two review fixes. Writing the probe exposed a second defect, in code this branch did not add. I bisected it to the probe rather than assume: The probe also listens on its own channel now, so a booting process no longer wakes every waiting role in the deployment. All four supported clients, 710 runs and 0 failures each: SQLite 38 skips, PostgreSQL 18 28 skips, mysql2 8.4 46 skips, Trilogy 8.4 46 skips. |
`reset!` cleared `@wake_up` directly, which now writes the memo without the lock that `wake_up` reads it under, and left the once-only pooled session warning armed. It calls `reset_wake_up!` instead. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
@greptileai review |
`wake_up_adapter = :postgresql` on a database with no notification channel built the adapter anyway and reported `crosses_processes: true`, so the doctor said PASS while every `LISTEN` failed. `:redis` without `SOLID_OBJECTS_REDIS_URL` reached for the client default rather than say it had no address. Each case now polls, logs `solid_objects.wake_up.unavailable` once, and records the reason in the capability, so the doctor warns. That is what the capability record exists for. Only a name that does not exist is still refused, because a typo cannot be honoured at all. `configure` validates the name now. An unknown one raised at the first wake-up, which is after a commit, rather than at boot. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The TypeScript suite covers this and the Ruby suite did not. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
@greptileai review |
The new configuration check required `watch`, but four runtime roles ask `respond_to?(:watch)` before they use it, and ADR 0011 records that an older adapter without it keeps the fast polling cadence. A supported adapter would have failed at boot. The check requires `signal` and `wait`, which every role calls without asking. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
@greptileai review |
Three documents still described the old opt-in behaviour. The realtime guide told readers to assign `WakeUpAdapters.for` and said Redis is never selected, the operations guide said the warning fires when no adapter is configured, and ADR 0011 recorded no decision about choosing one. Each now describes the setting, automatic selection, the probe, the downgrade that says why, and where to read the installed capability. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
@greptileai review |
Closes #72.
Why
A commit in a web process did not wake a worker process unless somebody
configured an adapter, so delivery waited out the polling interval, up to the
one-second
idle_polling_intervaldefault. The adapters existed and weremeasured. Nothing chose between them.
One setting, not two
My first draft added
config.wake_up = :automaticbeside the existingconfig.wake_up_adapter. That was wrong. Rails uses one setting that takeseither a name or an object, never a parallel pair:
config.cache_storeaccepts:redis_cache_storeor a cache object, andconfig.active_job.queue_adapteraccepts
:sidekiqor an adapter.So
config.wake_up_adaptertakes both:An unknown name raises
ArgumentErrornaming the accepted values, rather thanquietly polling.
configurevalidates it, so a typo fails at boot rather thanat the first wake-up, which is after a commit. A typo that silently costs a
second of latency is the failure this change exists to remove.
A name that exists but that the environment cannot provide is different. It
polls, logs
solid_objects.wake_up.unavailableonce, and records the reason inthe capability, so the doctor warns.
:postgresqlon a database with nonotification channel used to build the adapter anyway and report
crosses_processes: true, so the doctor said PASS while everyLISTENfailed.:rediswithoutSOLID_OBJECTS_REDIS_URLused to reach for the client defaultrather than say it had no address.
Selection
SOLID_OBJECTS_REDIS_URLselects Redis, on any database.LISTEN, unless a probe notification fails to arrive.The probe proves delivery
LISTENdoes not survive a transaction pooler such as PgBouncer, so the path isprobed before it is chosen.
My first version set
application_nameand read it back on the next statement.Review was right that this proves nothing. A pooler can hand the same backend to
two consecutive autocommit statements, so the read-back succeeds while
LISTENstill has no session affinity, and selection would memoise a notification path
that never fires.
Selection now proves the path end to end. It listens on a probe channel, sends
one
NOTIFYfrom a second connection, and waits up to two seconds for it toarrive.
Three points about the shape:
drops the asynchronous notification, because the client connection is not
bound to the backend that ran
LISTEN.NOTIFYgoes through a separate connection rather than the pooled one.NOTIFYdelivers at commit, so a caller that selects inside an opentransaction would otherwise never see its own probe.
polling, which costs latency and not correctness. The probe closes both
connections, so selection leaves nothing open.
process would wake every waiting role in the deployment once.
Selection runs once per process
The probe found a second defect, in code this branch did not add.
SolidObjects.wake_upmemoised with||=and no lock. One selection was cheap,so the race cost nothing visible. A probe that opens connections and waits for a
notification is not cheap. Eight threads that raced for the first use each ran a
full selection, took every free pool connection, and left four callers waiting
out the checkout timeout:
Selection now runs under a mutex, so one thread probes and the rest read the
result. A new test holds it: eight threads race, and selection must run once.
Without the lock it failed with
Expected: 1, Actual: 8.Reporting the choice
bin/rails solid_objects:doctorreports the same record:PASSwhen it crossesprocesses,
WARNwith the reason when it does not.This also fixed a bug the change would otherwise have introduced. The
polling-only warning guarded on
return if configuration.wake_up_adapter, whichis now always truthy, so the warning would have stopped firing everywhere. It
guards on
capability.crosses_processesinstead, which is what it meant to ask.An adapter that reports its own capability keeps it. A configured
SolidObjects::WakeUpsignals in one process only and says so, so the doctorwarns and the process registry keeps its polling warning, rather than have
selection relabel it as cross-process. An adapter that reports no capability is
somebody else's and is still assumed to cross processes, as before.
Behaviour change
PostgreSQL deployments that configure nothing now use notifications. They
gain cross-process wake-up, and with it a connection per waiting thread outside
the pool and one
NOTIFYper enqueue after the commit. TheNOTIFYlands afterCOMMIT, so it does not widen the lock window, but it is one more round tripper enqueue.
config.wake_up_adapter = :in_processkeeps the old behaviour.Tests
test/integration/wake_up_selection_test.rbis new, 22 tests: an explicitadapter wins, a configured adapter keeps the capability it reports about itself,
the doctor warns about a configured in-process adapter, an adapter that reports
none is recorded as configured, racing threads select once, a requested
:postgresqlpolls when the database has no channel, a requested:redispolls when no URL is set, a listener wakes from a notification sent on another
connection,
:in_processopts out, a name selects withoutprobing, an unknown name is refused when the configuration is validated and again at
selection, a Redis URL wins on any database, PostgreSQL
selects notifications when a probe notification arrives, PostgreSQL polls when
one does not, a pooled session falls back and warns once, a database without a
channel reports its floor, the capability names the adapter actually installed,
an unreachable database degrades to in-process rather than raising, an adapter
without
watchis accepted, one that cannot signal is refused, and the doctorreports it.
configurevalidates the setting, and the check follows the contract the rolesactually use. Four roles ask
respond_to?(:watch)before they call it, andADR 0011 records that an adapter without it keeps the fast cadence, so the check
requires
signalandwaitonly.Each of these failed first. The two that hold the review findings failed for the
stated reason:
test/unit/wake_up_adapters_test.rbnow stubs the probe in both directions, sofamily routing is asserted without depending on which database the suite runs
against.
Two existing tests moved to
:in_processrather than changing what they assert.The polling warning test is about the warning, not about selection. The enqueue
statement count is about the enqueue transaction, not about the
NOTIFYacross-process adapter adds after it. That count guard is what caught the extra
statement in the first place.
Validation
bundle exec rakepasses. Steep reports no type error, Brakeman no warning.The positive PostgreSQL case is measured rather than assumed. The selection file
finishes in 0.93 s, so the probe notification arrived rather than ran out its
two-second timeout.
What this does not do
The issue's third part asks for a measurement before choosing a MySQL notifier,
between a tighter poll floor, a signal table, and
GET_LOCK. No MySQLnotifier is shipped here, so no measurement gates this change: MySQL still
polls, and the capability now says so and reports its floor rather than leaving
an operator to infer it. Choosing one of those three still needs the benchmark
the issue describes, and belongs in its own change.